Skip to content

fix(watch): enforce ClusterProvider admission wherever rules compile#258

Merged
sunib merged 1 commit into
mainfrom
feat/clusterwatchrule-provider-admission
Jul 20, 2026
Merged

fix(watch): enforce ClusterProvider admission wherever rules compile#258
sunib merged 1 commit into
mainfrom
feat/clusterwatchrule-provider-admission

Conversation

@sunib

@sunib sunib commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Implements PR 3 of the source-namespace addressing plan. Independent of the other four PRs. Bug fix — no API change, no CRD regeneration.

The defect

ClusterWatchRule.targetRef is a NamespacedTargetReference with a required namespace, and the reconciler resolved it with a plain r.Get and no authorization check of any kind. So a ClusterWatchRule could attach to a GitTarget in any namespace and widen that target's mirror scope cluster-wide, without the compilation path ever consulting the ClusterProvider's allowedNamespaces admission for that target's namespace. bootstrap.go seeded rules the same way.

This was not a live escalation. ClusterWatchRule is cluster-scoped, so only a config-plane cluster-admin can create one, and that subject can already read the kubeconfig Secrets directly. The gate also held transitively: checkSourceAuthorization runs inside the Validated gate and returns before DeclareForGitTarget, which is what populates targetWatches — and refreshRunningTargetWatches skips any table whose destination is not already running. An unauthorized rule built a resident table but started no stream.

It is worth fixing anyway because that protection is incidental. It depends on statement ordering inside a controller that nothing requires anyone to preserve, with no test to catch a refactor of DeclareForGitTarget that silently converts it into a real hole. And it makes allowedNamespaces mean what it says: a platform admin reading a provider's admission list should be able to conclude that no rule anywhere mirrors through that credential on behalf of an unadmitted target.

What changed

One shared decision. internal/authz.GitTargetAdmitted now holds the provider-existence and namespace-admission check, applied by all three call sites that compile a rule or start a data plane for a GitTarget: the GitTarget reconciler, the ClusterWatchRule reconciler, and the watch manager's startup bootstrap. Bootstrap matters specifically because it runs before the first reconcile on every restart — a helper only the reconciler used would leave that whole window unguarded.

A new package was necessary rather than stylistic: internal/controller imports internal/watch, so neither could host a helper the other calls without an import cycle.

Refusal stops the data plane before it publishes status. The compiled rule is dropped and the watch manager replanned, and only then is GitTargetNamespaceNotAuthorized written with the terminal kstatus trio (Ready=False, Reconciling=False, Stalled=True). A gate that only writes a condition leaves the stream running while announcing that it is not.

A read error requeues rather than refuses. A transient apiserver failure must not tear down a running stream, so the compiled rule is left in place and the error is returned.

Revocation converges on the event. Two new mappers — ClusterProvider → ClusterWatchRules and Namespace → ClusterWatchRules — cover the two ways a policy can change. The GitTarget's own status flip cannot carry this: it is a status-only update, which the existing GenerationChangedPredicate drops. Without the mappers a revocation would converge only on the ~10 minute periodic reconcile.

The RBAC markers added to the ClusterWatchRule controller produce no manifest diff — both reads were already granted cluster-wide by the GitTarget controller's markers. They document this controller's actual reads.

Tests

internal/authz is at 100% statement coverage. Beyond the cases the plan lists, the table covers the deny-by-default shapes (nil policy, empty struct), the Names/Selector OR (a listed namespace is admitted even when the selector rejects it — guards a future refactor turning the OR into an AND), an invalid selector denying rather than admitting, and the absent-Namespace split (a names entry still admits; a selector correctly does not).

I verified the tests bite rather than trusting green. Three mutations against the merged code:

Mutation Result
Gate removed entirely (the pre-fix behavior) 5 tests fail
Status-only gate — condition written, data plane never stopped 3 tests fail
Correct teardown, but performed after the status write only the ordering test fails

The third is the discrimination the plan explicitly asked for: it isolates ordering from end-state, and the other two tests correctly still pass under it because the final state is identical.

Two things worth your attention

1. I modified an existing test. TestManagerStart_MustSeedRuleStoreFromExistingClusterWatchRules had a fixture whose GitTarget omits clusterProviderRef (resolving to default) with no ClusterProvider object present — so the new gate correctly refused it. I added the provider the way internal/controller/suite_test.go already ships it for envtest ("the way a real install does", empty selector admitting every namespace). That is completing an incomplete fixture, not weakening a test: the refusal path it no longer exercises is now covered explicitly by TestBootstrapClusterWatchRule_RefusesMissingClusterProvider. Flagging it because altering a test to make a change pass deserves a second pair of eyes.

2. bootstrapWatchRule was deliberately left ungated. A namespaced WatchRule's target lives in its own namespace, so that edge cannot cross a namespace boundary the way ClusterWatchRule's required targetRef.namespace can, and the GitTarget's own reconcile gate already governs whether that target's data plane runs. This matches the plan's scope, but it is a judgment call rather than a forced one.

No e2e spec was added. Per the existing note that refused-GitTarget recovery is racy to assert end-to-end (the GitPathAccepted projection is racy in both directions), a spec here would be a flake generator; the ordering contract is pinned deterministically at the unit level instead.

Validation

  • task lint — 0 issues
  • task test — green; coverage ratchet auto-raised 76.7% → 77.0% (.coverage-baseline committed)
  • task test-e2e58 passed, 0 failed, 22 skipped, 11m41s

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added namespace admission checks for cluster watch rules and Git targets based on cluster provider policies.
    • Unauthorized rules are refused, removed from active processing, and reported with clear status conditions.
    • Admission is re-evaluated automatically when provider policies or namespace labels change.
    • Startup now skips unauthorized rules while continuing to initialize approved rules.
  • Documentation

    • Updated design documentation to reflect the delivered admission behavior.
  • Tests

    • Added coverage for authorization, revocation, startup behavior, error handling, and policy change detection.

A ClusterWatchRule resolved its GitTarget with a plain Get and no
authorization check, so it could attach to a GitTarget in any namespace
and widen that target's mirror scope cluster-wide without the
compilation path ever consulting the ClusterProvider's allowedNamespaces
policy for that target's namespace.

This was not a live escalation — ClusterWatchRule is cluster-scoped, and
the gate held transitively because checkSourceAuthorization returns
before DeclareForGitTarget, so no targetWatches entry ever existed for
refreshRunningTargetWatches to start a stream from. But that protection
was incidental: it depended on statement ordering inside a controller
that nothing required anyone to preserve, with no test to catch a
refactor that broke it.

Move the decision to internal/authz.GitTargetAdmitted and apply it at
every call site that compiles a rule or starts a data plane for a
GitTarget: the GitTarget reconciler, the ClusterWatchRule reconciler,
and the watch manager's startup bootstrap. The package is new because
internal/controller imports internal/watch, so neither could host a
helper the other calls.

A refusal stops the data plane before it publishes status — the
compiled rule is dropped and the watch manager replanned, then
GitTargetNamespaceNotAuthorized and the terminal kstatus trio are
written. A gate that only writes a condition leaves the stream running
while announcing that it is not. A read error requeues rather than
refusing, so a transient apiserver failure cannot tear down a running
stream.

Two new mappers (ClusterProvider -> ClusterWatchRules, Namespace ->
ClusterWatchRules) make a revocation converge on the event rather than
on the ~10 minute periodic reconcile; the GitTarget's own status flip
cannot carry it, since GenerationChangedPredicate drops status-only
updates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1c6fa4a2-8b4c-4dc9-ab90-7e1bacd082d6

📥 Commits

Reviewing files that changed from the base of the PR and between e41f69c and 919d184.

📒 Files selected for processing (11)
  • .coverage-baseline
  • docs/design/watchrule-source-namespace/README.md
  • docs/design/watchrule-source-namespace/pr3-clusterwatchrule-target-admission.md
  • internal/authz/clusterprovider_admission.go
  • internal/authz/clusterprovider_admission_test.go
  • internal/controller/clusterwatchrule_admission_test.go
  • internal/controller/clusterwatchrule_controller.go
  • internal/controller/gittarget_source_cluster.go
  • internal/watch/bootstrap.go
  • internal/watch/bootstrap_admission_test.go
  • internal/watch/manager_startup_test.go

📝 Walkthrough

Walkthrough

The change centralizes GitTarget namespace admission through authz.GitTargetAdmitted, applies it to GitTarget and ClusterWatchRule reconciliation plus startup bootstrap, adds event-driven policy re-evaluation, and expands coverage and design documentation.

Changes

Namespace admission enforcement

Layer / File(s) Summary
Shared admission decision
internal/authz/clusterprovider_admission.go, internal/authz/clusterprovider_admission_test.go
Adds GitTargetAdmitted, shared denial reasons, the Decision contract, policy evaluation, and read-error handling tests.
GitTarget authorization integration
internal/controller/gittarget_source_cluster.go
Delegates GitTarget authorization to the shared admission evaluator and uses canonical reason values.
ClusterWatchRule enforcement and requeueing
internal/controller/clusterwatchrule_controller.go, internal/controller/clusterwatchrule_admission_test.go, docs/design/watchrule-source-namespace/*, .coverage-baseline
Adds admission refusal handling, data-plane teardown ordering, RBAC, ClusterProvider and Namespace watches, targeted mappers, reconciliation tests, and updated design and coverage records.
Startup bootstrap admission
internal/watch/bootstrap.go, internal/watch/bootstrap_admission_test.go, internal/watch/manager_startup_test.go
Checks admission before seeding ClusterWatchRules and verifies refused rules are skipped while admitted rules initialize successfully.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ClusterWatchRuleReconciler
  participant authz.GitTargetAdmitted
  participant KubernetesAPI
  participant RuleStore
  participant WatchManager
  ClusterWatchRuleReconciler->>authz.GitTargetAdmitted: evaluate referenced GitTarget
  authz.GitTargetAdmitted->>KubernetesAPI: read ClusterProvider and Namespace
  authz.GitTargetAdmitted-->>ClusterWatchRuleReconciler: return admission decision
  ClusterWatchRuleReconciler->>RuleStore: remove refused compiled rule
  ClusterWatchRuleReconciler->>WatchManager: replan stream state
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.36% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the PR’s main change: enforcing ClusterProvider admission during rule compilation.
Description check ✅ Passed The description is detailed and covers the change, testing, and notes, but it does not follow the template sections like Type of Change or Checklist.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/clusterwatchrule-provider-admission

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.43662% with 15 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/controller/clusterwatchrule_controller.go 87.2% 8 Missing and 4 partials ⚠️
internal/watch/bootstrap.go 57.1% 2 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant